Skip to content

Route intercepted tags by id instead of by name - #12817

Open
dougqh wants to merge 21 commits into
masterfrom
dougqh/id-tag-interceptor
Open

dougqh wants to merge 21 commits into
masterfrom
dougqh/id-tag-interceptor

Conversation

@dougqh

@dougqh dougqh commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

What Does This Do

Moves TagInterceptor from name-keyed to id-keyed dispatch. This is groundwork for instrumentation setting tags by KnownTags.*_ID constants.

  • Intercepted bit in the id. A new Java overlay, tag-conventions-java.yaml, sits next to the language-agnostic conventions. It declares the keys that exist only to be routed (resource.name, error, span.type, manual.keep, sampling.priority, ...) and lists every tag TagInterceptor may route. Each listed tag's id carries KnownTagCodec.INTERCEPTED (bit 1).
  • TagInterceptor switches on the tag's serial. A known tag is matched under any of its names, so the *_OTEL_NAME cases are gone. split-by-tags entries resolve to a fixed-size table indexed by serial at construction, so the check is a single load; a name declared per direction (peer.port) covers both directions. Only custom tags are still matched by name.
  • DDSpanContext sets by id first. A name setter resolves the id once and then takes the id path. The id setters no longer look up the name to feed the interceptor. Entry paths (builder ledger, prototypes, default tags) route by entry.tagId() and check before boxing.
  • nameOf reads a generated NAMES_BY_SERIAL array instead of a switch. It was 491 bytes, "hot method too big" for C2; it's now 23 bytes and inlines.
  • Custom tags are built by name, known tags by id. Entry's package-private name factories are custom-only: they skip the registry and assert the name isn't a known tag. Callers that start from a name resolve it once. TagMap.set(String, ...) forwards a known tag to set(long, ...); getAndSet, put, Ledger.set, putAll and the public Entry.create(String, ...) resolve through anyEntryFor and its siblings. A custom tag now pays one registry lookup instead of two: TagMapInsertBenchmark's custom case is 8–12% faster.
  • Shared-name ids are rejected on the id paths for now. A tag declared once per direction (peer.port) has two ids but one Datadog name, which keyOf resolves to neither. Until name resolution knows a span's direction (Resolve direction-dependent tag names by the span's kind (otlp - tag registry - phase 3) #12731), a SHARED_NAME id bit marks those ids. TagMap's id-keyed create, get and remove reject them, and the span's id setters ignore them (KnownTagCodec.isKeyableById). The check folds away for a constant id.
  • TagMap.internals(): a restricted, trusted path for the tracer core. Its setKnown(...) skips the id validation that set(long, ...) repeats. The span's id setters, which have already checked isKeyableById, use it. Internals is created per call, so escape analysis removes it. A new @Restricted(allowedIn = ...) annotation marks internals(), and gradle/forbiddenApiFilters/instrumentation.txt bans it in instrumentation modules (verified: a call from an instrumentation module fails forbiddenApisMain). Internals is also where the batched setTagsFrom/TagSink writes will go.

Motivation

With a constant id, setTag(KnownTags.X_ID, v) should inline completely and fold away both the interception test and the custom-tag path. That leaves the store. Once the dense store puts the slot coordinate into the id, the store becomes direct too.

C2 inline trees (Zulu 17), steady state:

  • setTag(HTTP_ROUTE_ID, ...), not intercepted: every call inlines. The isIntercepted test folds and interceptTag disappears.
  • setTag(SPAN_KIND_ID, ...), intercepted: the same, except for one out-of-line call to the interceptTag switch (483 bytes).

Before the nameOf change, both trees made two out-of-line calls into the 491-byte nameOf switch on every set.

Insert cost (TagMapInsertBenchmark, 12 tags per op, C2 on Zulu 17, two runs per side), against the commit before the registry (#12354):

pre-registry this PR
known tags by name 74.0 ns, 736 B 108.5 ns, 784 B
known tags by id n/a 74.8 ns, 784 B
custom tags by name 60.8 ns, 640 B 78.4 ns, 736 B

Setting by id is back to pre-registry speed. By name costs about 3 ns per tag for canonicalization (a keyOf hit, then nameOf). Custom tags pay a keyOf miss and 8 bytes per entry; that's a follow-up. The dense store is where ids get ahead of pre-registry.

Span setTag (SetTagBenchmark, C2 on Zulu 17) is about 13.5 ns whichever way the tag is named, against 5.8 ns for a bare TagMap store. The span's uncontended synchronized is most of the difference, which is why it hides the per-tag gain. Batching (setTagsFrom with one lock per batch) and the dense store are the steps that reach it.

Additional Notes

  • Stacked on Set known tags by id on TagMap and spans (otlp - tag registry - phase 2) #12715 through a merge commit (Merge set-by-id (#12715) onto master), with a later merge from master for the CODEOWNERS team rename (@DataDog/apm-sdk-capabilities). Review this PR's own, non-merge commits: git log --first-parent --no-merges 7a017a5657..dougqh/id-tag-interceptor. I'll rebase once Set known tags by id on TagMap and spans (otlp - tag registry - phase 2) #12715 lands.
  • Drift: the intercepted bit was taken out earlier because it drifted from the interceptor's switch. exactlyTheTagsWithTheInterceptedBitHaveACase runs every known id through the switch and requires a case exactly when the bit is set. Removing error from the overlay makes it fail.
  • Fix split-by-tags for tags configured by OpenTelemetry name #12814 (the short-term split-by-tags fix) is superseded by this PR: id matching covers OTel names. Whichever lands second drops withCanonicalNames.
  • Unknown and shared-name ids are ignored by the span API, while TagMap's checked id routines throw. The span's guard is KnownTagCodec.isKeyableById: a range check against a generated SERIAL_LIMIT, plus the SHARED_NAME bit, both of which fold away for a constant id.
  • interceptTag(long) stays too big for C2 to inline (over FreqInlineSize, 325 bytes of bytecode), on purpose. If it inlined, setTag's standalone compiled code would pick up the profiled handler bodies and grow past InlineSmallCode (2500 bytes), and callers would stop inlining setTag. TagInterceptorInliningTest pins the size. A small, foldable handler switch (dense intercepted serials, constant handlers) was tried and works, but it doesn't fit until the dense store shrinks setTag's store path, so it's deferred to that PR.
  • Inlining headroom: at the PR head, the standalone C2 compile of DDSpan.setTag(long, String) is about 2,300 bytes, against the 2,500-byte InlineSmallCode limit for inlining an already compiled method into callers. That margin is worth watching as the set path changes.
  • Generator: the tracer overlay is optional input (tracerOverlayFile), and overlay tags join no span type's resolved set.

🤖 Generated with Claude Code

dougqh and others added 10 commits October 9, 2026 08:28
Adds id-keyed setters, getters and removal to TagMap, TagMap.Entry and
spans, so a writer that knows the tag skips the name lookup. Squashed
from the review history of #12715.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
span.setTag(0L, "") or a null value reached TagMap.getAndRemove(long),
which rejects unknown ids, so clearing threw IllegalArgumentException
while every id-keyed setter ignores an unknown id. removeTag(long) now
ignores it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The long-valued setters prechecked with needsIntercept, but when the
interceptor declined the tag they discarded the box it was given and stored
the primitive. Follow the same precheckIntercept -> setBox shape as the
other primitive setters, so TagMap keeps the box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
peer.port is declared per direction in the registry (#12713), so it has no
direction-free id: BaseDecorator, shared by client and server decorators,
sets it by name, and the set-by-id tests use an unshared int tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Set-path routing is a per-language concern, so it lives in a Java overlay
next to tag-conventions.yaml rather than in the language-agnostic
conventions. The overlay declares the keys that exist only to be routed
(resource.name, error, sampling directives, ...) and lists every tag
TagInterceptor may route. Each listed tag's id carries the INTERCEPTED bit
(bit 1), so a setter called with a constant id can fold the interception
test away. Serial numbers become public so TagInterceptor can switch on
them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TagInterceptor switches on the tag's serial rather than its name, so a
known tag is matched under any of its names and the *_OTEL_NAME cases go
away. needsIntercept(long) tests the INTERCEPTED bit first, which folds
away for a constant id. split-by-tags entries resolve to serials at
construction (each direction for a name declared per direction); only
custom tags are still matched by name. A test checks that exactly the
tags carrying the bit have a case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A name setter resolves a known tag's name to its id once and sets it as the
id-keyed setters do; only a custom tag is still handled by name. The
id-keyed setters no longer look up the name to feed the interceptor, so with
a constant id the interception test folds to the INTERCEPTED bit. Entry
paths (builder ledger, prototypes, default tags) route by the entry's id
and precheck before boxing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The generated nameOf switched over every tag's serial: 491 bytes of
bytecode, too big for C2 to inline, so each id-keyed set paid two
out-of-line calls into it (the span's unknown-id guard and the entry's
name). Read a NAMES_BY_SERIAL array instead; nameOf is now 23 bytes and
inlines at every caller.

Also adds SetTagBenchmark (span setTag by constant id, non-constant id,
name, and custom name, against a bare and a synchronized TagMap store).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dougqh dougqh added comp: core Tracer core tag: no release notes Changes to exclude from release notes type: refactoring tag: ai generated Largely based on code generated by an AI or LLM labels Oct 9, 2026
@datadog-prod-us1-6

This comment has been minimized.

@dd-octo-sts

dd-octo-sts Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

🟢 Java Benchmark SLOs — All performance SLOs passed

Suite Status
Startup 🟢 pass

SLO thresholds are defined here based on automatically generated metrics. A warning is raised when results are within 5% of the threshold.

PR vs. master results
Scenario Candidate master Δ (95% CI of mean)
startup:insecure-bank:iast:Agent 13.99 s 13.94 s [-0.3%; +1.0%] (no difference)
startup:insecure-bank:tracing:Agent 12.85 s 12.98 s [-1.7%; -0.2%] (maybe better)
startup:petclinic:appsec:Agent 17.16 s 17.07 s [-0.4%; +1.4%] (no difference)
startup:petclinic:iast:Agent 16.87 s 16.98 s [-1.3%; +0.0%] (no difference)
startup:petclinic:profiling:Agent 16.53 s 16.66 s [-1.6%; +0.0%] (no difference)
startup:petclinic:sca:Agent 16.99 s 16.90 s [-0.2%; +1.3%] (no difference)
startup:petclinic:tracing:Agent 15.67 s 16.06 s [-6.6%; +1.8%] (no difference)

Commit: fbab5517 · CI Pipeline · Benchmarking Platform UI


Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion.

dougqh and others added 5 commits October 9, 2026 12:03
The table is always allocated and never resized, so the split check -- the
only run-time check left on a non-intercepted constant-id set -- is a single
load, with no null or length test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The span's unknown-id guard called nameOf; KnownTagCodec.isKnown answers the
same question from the serial and a generated SERIAL_LIMIT, so it folds
away for a constant id. Also note that serials are not stable across
releases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DDSpanContext.setTag calls interceptTag only for intercepted tags. Kept out
of line (over FreqInlineSize, 325 bytes of bytecode), it never brings the
profiled handler bodies into setTag's compiled code, which would push setTag
past InlineSmallCode and stop callers inlining it -- the inlining a constant
id needs to fold its interception test. A test now fails if the switch
shrinks below the limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An overlay tag exists only to be routed, so the generator now marks it
intercepted without a second listing; intercepted: lists only the tags
declared in tag-conventions.yaml that the tracer also routes. Also restore
the doc comment and @Suppress that the overlay helper displaced, read the
serial limit one way, inline isUnknownTag, and drop an unused default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dougqh
dougqh marked this pull request as ready for review October 9, 2026 19:32
@dougqh
dougqh requested review from a team as code owners October 9, 2026 19:32
@dougqh
dougqh requested review from AlexeyKuznetsov-DD and BridgeAR and removed request for a team October 9, 2026 19:32
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-09T19:38:03.485338Z 3f65031 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3f65031560

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +477 to +478
long tagId = KnownTagCodec.keyOf(tag);
return tagId != 0 ? tagId : customHash(tag);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve lookups for direction-specific tag IDs

When a tag such as peer.port is stored using PEER_PORT_INBOUND_ID or PEER_PORT_OUTBOUND_ID, the entry is bucketed by that ID, but keyOf("peer.port") deliberately returns zero because the shared name has no direction; this method therefore hashes a subsequent name-based lookup as a custom string. As a result, get, containsKey, and remove by the map's exposed string key cannot find the entry (and storing both IDs can produce duplicate peer.port keys), violating the Map<String, Object> contract for the new ID setters. Direction-specific entries need a hash compatible with name-based access, or those IDs must be rejected by TagMap.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, confirmed. peer.port is the only shared-name tag, and nothing in production sets it by id yet. Rather than special-casing the hot lookup path, 69d7849 encodes a SHARED_NAME flag in the reserved id bit 0. The id-keyed TagMap paths reject those ids, and the span setters ignore them, until name resolution knows the span's direction.

Written by Claude, reviewed by @dougqh

@datadog-prod-us1-6 datadog-prod-us1-6 Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bits Code Review: FAIL

Directional peer.port ids no longer share a bucket identity with their Datadog name, so name-based reads and clears miss stored values. Mixing id-based and name-based setters can also create duplicate tag keys.

Open Bits AI session

🤖 Bits Code Review · Commit 3f65031

private Entry(long tagId, byte type, long prim, Object obj) {
// Resolve the name once: it both validates the id and names the entry.
super(requireKnownName(tagId));
this.tagHash = tagId;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Preserve name-based access for directional tag ids

After setting PEER_PORT_INBOUND_ID or PEER_PORT_OUTBOUND_ID, getTag("peer.port") misses the value and clearing by name leaves it intact. The entry hashes the id, whereas name-based operations hash "peer.port" because keyOf returns zero for that shared name. Setting the name afterward can create duplicate tag keys instead of replacing the value. Align the bucket identity across id and name operations while preserving directional metadata, and update tests that currently expect separate entries.

Was this helpful? React 👍 or 👎
🤖 Bits Code Review · Open Bits AI session

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, confirmed. peer.port is the only shared-name tag, and nothing in production sets it by id yet. Rather than special-casing the hot lookup path, 69d7849 encodes a SHARED_NAME flag in the reserved id bit 0. The id-keyed TagMap paths reject those ids, and the span setters ignore them, until name resolution knows the span's direction.

Written by Claude, reviewed by @dougqh

@AlexeyKuznetsov-DD AlexeyKuznetsov-DD left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM
My local codex found and confirmed same issue that GitHub codex highlighted.
Left minor comments about possible improvements.

Comment thread dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java Outdated
dougqh and others added 3 commits October 9, 2026 17:57
A tag declared once per direction (peer.port) has two ids but one
shared Datadog name, and keyOf resolves that name to neither. An entry
keyed by one of those ids hashed by the id while name-based access
hashed the name as a custom tag, so get/remove/containsKey by name
missed it and a later set by name produced a duplicate key.

Encode a SHARED_NAME flag in the reserved id bit 0 so the check folds
for a constant id. TagMap's id-keyed create/get/remove reject such ids,
and DDSpanContext's id-keyed setters ignore them, as they do unknown
ids, until name resolution knows the span's direction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entry's package-private name factories are now custom-only: they skip the
registry and assert the name is not a known tag. Callers that start from a
name resolve it once -- TagMap.set(String, ...) forwards a known tag to
set(long, ...), while getAndSet, put, Ledger.set, putAll and the public
Entry.create(String, ...) API go through anyEntryFor and friends -- so a
custom tag pays one registry lookup instead of two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dougqh added a commit that referenced this pull request Oct 10, 2026
#12817 rejected shared-name ids (peer.port inbound/outbound) on the id
paths until name resolution could see a span's direction. With direction
resolution in place, accept them again: a read or removal by the shared
name already checks each direction's id. To keep one entry per tag, a write
by the shared name stores under whichever direction's tag the map holds
(otherwise under the name, which the span re-keys once it has a
direction), and a write by id drops a value held under the name alone.
isKeyableById goes; the SHARED_NAME bit stays and keeps the new check
foldable for a constant id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	.github/CODEOWNERS
dougqh and others added 2 commits October 10, 2026 12:18
TagMap.internals() returns a small Internals view whose setKnown(...) skips
the id validation the checked set(long, ...) repeats; DDSpanContext's
id-keyed setters, which have already checked isKeyableById, now use it.
Internals is created per call, so escape analysis removes it.

internals() is marked with a new @restricted(allowedIn = ...) annotation,
and gradle/forbiddenApiFilters/instrumentation.txt bans it in
instrumentation modules, so calling it there fails forbiddenApisMain.
Internals is also where the batched setTagsFrom/TagSink writes will go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It exists to be created, used within the expression, and scalar-replaced;
storing one would force a real allocation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp: core Tracer core tag: ai generated Largely based on code generated by an AI or LLM tag: no release notes Changes to exclude from release notes type: refactoring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants